fix: Narrow conditions for load_value to give invalid address - #6157
fix: Narrow conditions for load_value to give invalid address#6157amjames wants to merge 11 commits into
load_value to give invalid address#6157Conversation
…ting python object fixes: pybind#6153 Objects initialized with `cls.__new__(cls)` (`cls` is a pybind11 bound type). Will not have the C++ object allocated. When hitting `load_value` storage is allocated but not initialized, calling a virtual method will load a garbage vptr and segfault. This is similar to pybind#2152, but the guard in metaclass `__call__` is not triggered when using `__new__`. Protect against giving a pointer to garbage in all cases except the `__init__` + `__setstate__` path. Authored with claude
…y allocation for old-style constructors If an old-style placement-new `__init__`/`__setstate__` failed after `self` was loaded, the lazily allocated storage stayed behind with a null-holder instance, so the uninitialized-value guard never fired again and later use read uninitialized memory. `instance_construction_scope` now tracks the constructor's `value_and_holder` and frees storage that was lazily allocated during a construction that did not complete. Also arm the scope only when the overload chain contains an old-style constructor. New-style constructors receive `self` directly and never need lazy allocation, so reentrant loads of the half-built instance now raise `ValueError` instead of handing out uninitialized storage. Assisted-by: ClaudeCode:claude-fable-5 Claude-Session: https://claude.ai/code/session_01TQXCSykMn5EL7sc6VgTUTC
|
I pushed two fixes from Fable. Both started with failing tests, then fixed. 🤖 AI text below 🤖 Review complete: 12 candidates checked, 2 survived (both confirmed), 10 refuted. 1. Incomplete fix — failed old-style 2. Guard armed too broadly ( Notable refutations: cross-module ABI safe (layout unchanged, bit zero-filled by |
espressolee
left a comment
There was a problem hiding this comment.
I independently reviewed exact head 66f8f3760f02cb596b28e552fc4f95cd79586b7a against base 5e9611aacc0bdd2054aa36800055014ebcd8e805 on macOS/arm64 with CPython 3.14.6 and 3.14.6t.
The submitted checks work for their covered paths: direct use of a __new__-only instance raises, failed old-style initialization is cleaned up, new-style constructor re-entry raises, pickle round trips survive, and test_class.py is 43/43 in regular Debug, regular NDEBUG, and free-threaded Debug builds.
There is still a blocking re-entry hole in the old-style placement-new path. instance_construction_scope marks the instance for the entire constructor dispatcher, and loading the old-style self argument lazily allocates vptr. If conversion of a later constructor argument executes Python and calls another bound method on the same object, that method sees the now-non-null raw pointer and bypasses the new null-value guard, even though placement-new has not run. A virtual call then reads the uninitialized vtable.
Minimal shape using the PR's own OldStyleInit fixture:
obj = m.OldStyleInit.__new__(m.OldStyleInit)
class Reenter:
def __index__(self):
obj.v_data() # dispatches through unconstructed storage
raise TypeError
obj.__init__(Reenter())Exact-head results, fresh processes:
- CPython 3.14.6 Debug: 5/5 SIGSEGV
- CPython 3.14.6
NDEBUG: 5/5 SIGSEGV - CPython 3.14.6t Debug with the GIL disabled: 5/5 SIGSEGV
- standalone exact-base build: 5/5 SIGSEGV
This is not a regression introduced by the PR, but it remains inside the same claimed invariant: old-style construction should be the only permitted lazy-allocation path without making arbitrary re-entrant native access safe. The current instance-wide flag cannot distinguish the constructor's own self load from a method call (or another thread) while construction is in progress.
Please scope the permission to the old-style constructor's own self conversion, and make every other load before successful construction raise. A regression where conversion of a later argument re-enters the same instance would cover the gap. I am not prescribing a particular implementation because preserving multi-overload old-style dispatch and free-threaded access requires the permission to be call/load-specific rather than merely an instance-wide time window.
Limits: I built the changed test_class module rather than the full local matrix; the exact-head remote matrix is green. The finding is deterministic in all three locally tested configurations above.
|
I'll take a stab at this using codex gpt-5.6-sol ultra. My starting point: VerdictPR 6157 should not merge at its current head, 66f8f37. The post-Henry review found a genuine blocking hole, and I independently reproduced it on your locally merged branch: the process terminates with What the PR gets rightThe original problem is real and security-relevant: direct The current PR correctly handles several important paths:
All four submitted focused tests pass, and the current GitHub matrix has 77 successful checks and two expected skips. Blocking findingThe permission is scoped to the entire instance and entire constructor dispatcher, rather than to the old-style constructor’s own The sequence is:
A virtual call then segfaults. No thread race is required; ordinary same-thread Python re-entry is sufficient. This is not a regression introduced by the PR, but it remains squarely inside the safety invariant the PR claims to establish. Green CI simply means this path is not tested. The same design issue also affects:
RecommendationsBefore merging:
The cleanest state model is conceptually:
Ideally, raw storage would not be published through the instance pointer until the constructor callback succeeds. If it must be published, a separate per-value “reserved but unconstructed” state must be checked before every load. |
Track construction per value-and-holder, grant a one-shot loader-frame permission only to the exact legacy constructor self conversion, and keep its raw storage private until the native callback returns. Reject reentrant, nested, cross-base, and cross-thread loads while preserving overload fallback, failure cleanup, pickle setstate callbacks, and repeated initialization behavior.
The new detail::instance construction state has cross-DSO semantics that internals-v12 modules do not understand. Isolate the incompatible domains for v3.2.0 and document that future structural or semantic instance changes require another bump.
Conclusion and release sequencingThis fix needs The proposed sequence is therefore:
For participating extensions that enable #5800's interoperability mechanism, supported conversions can then cross the v12/v13 boundary through the general foreign-type path. The internals boundary continues to provide isolation, while cross-version interoperability has the documented limitations and modest extra cost of that path rather than relying on unsafe shared state. Why retaining internals v12 would be unsafe
It has no representation for "this exact value slot is currently being constructed; do not load or initialize it." The new implementation keeps old-style placement-new storage private until the callback has successfully returned, leaving the instance's value pointer null in the meantime. An older v12 module can therefore bypass the new protocol, allocate and publish different raw storage, and hand it to bound C++ code before any object lifetime has begun. Substituting a non-null sentinel does not help: old code would treat the sentinel itself as a valid C++ pointer. Either route can reintroduce the undefined behavior this PR is intended to eliminate. The bump is consequently required by the changed cross-DSO semantics, not merely by the physical size or offsets of Alternatives consideredPrivate storage and null rejection without shared construction stateKeeping old-style storage private and rejecting ordinary null loads is sufficient for the original reproducer and many re-entrant cases when all participating code uses the new headers. It is not a complete replacement for the construction-state flag. In particular, two threads can begin initializing the same value slot, each placement-construct a private object, and discover the collision only when committing. pybind11 has no generic type-erased operation that can correctly destroy the losing object before its holder has been constructed. More importantly, an already-compiled v12 module would still follow its legacy null-pointer allocation path. Narrowing the fix in this way would therefore weaken the guarantee without avoiding the cross-version incompatibility. Reusing an existing flag or a pointer sentinelThe existing flags describe independent holder, registration, ownership, aliasing, and layout state. Overloading one of them would make normal initialization and cleanup depend on ambiguous flag combinations. A pointer sentinel is unsafe because every older caster, along with other paths that assume a non-null value pointer denotes a live object, can expose or dereference it. A synchronized side tableA shared registry keyed by the exact This would be a substantial one-off shared-state mechanism to represent one per-slot bit, with additional allocation, hashing, locking, and teardown concerns. It also would not eliminate the internals bump: old v12 code would not consult the table and could still expose unconstructed storage. DecisionOnce the v12/v13 boundary is recognized as necessary, storing the construction state directly in the value slot is the smallest and clearest design. It is naturally shared by modules in the same internals domain, is exact for Python multiple-inheritance layouts, and avoids a separate lookup and lifetime-management subsystem. The chosen approach is therefore to keep the explicit per-value construction state, bump to internals v13 for 3.2.0, and use #5800 as the general bridge for supported conversions between extensions that deliberately live in different internals domains. |
|
@oremanj for visibility |
|
@amjames @henryiii @espressolee Could you please make another pass over this PR with your agents? |
espressolee
left a comment
There was a problem hiding this comment.
I made another pass over exact head 14e32ae23af529df8d82681c2d3064884b259a3c. I do not see a remaining blocker in this PR.
One process note: the auxiliary peer-agent calls did not return a usable review, so none of the conclusions below rely on peer output. I performed the exact-head code pass and the additional probes directly.
The v13 bump is justified by protocol compatibility, not sizeof(detail::instance). I added a small two-extension cross-DSO probe to test that distinction:
- fixed producer
4455e3fand a legacy-v12 consumer shared the registered C++ object successfully: 20/20 fresh regular processes and 20/20 fresh 3.14t processes; - current-v13 producer and current-v13 consumer retained normal interoperability: 20/20 in both configurations;
- a current-v13 object passed to that legacy-v12 consumer was rejected with
TypeError: 20/20 in both configurations. On 3.14t the GIL remained disabled throughout.
That is the behavior the internals split needs to provide: v12 inline caster code no longer interprets a v13 instance using the old null/non-null protocol, while modules in the new domain still interoperate normally.
I also reran the original later-argument re-entry control. The blocked head 66f8f37 segfaults in 5/5 fresh Debug processes; current head rejects the same path safely in 20/20 regular and 20/20 free-threaded processes. The focused test_class.py suite is 49/49 in regular Debug and 49/49 in 3.14t Debug. GitHub currently reports 76 passing checks and two expected skips.
So the proposed sequence looks sound to me: isolate this construction protocol in internals v13 for 3.2.0, and treat #5800 as the explicit interoperability path for supported cross-domain conversions. I did not independently validate #5800's bridge behavior in this pass, so my approval is scoped to #6157's construction-state fix and the v12/v13 isolation at this exact head.
|
I'm nearly sure this does not require a ABI bump, and I asked Fable 5.1 about it too: 🤖 AI text below 🤖 No, I don't think the bump is justified. The argument on #6157 for v13 is that v12 inline caster code, given a v13 instance mid-construction, would still lazily allocate storage. That's true, but it is the pre-existing v12 bug, not a new incompatibility. In every mixed scenario I can construct, behavior is identical to pure v12. The bump doesn't make v12 modules safer; it only stops v12 and v13 modules from sharing types at all, which is a large cost for a bug fix. The concrete checks:
The rest of the comment (thread collision, sentinel pointers, side tables) argues for why the flag should exist in v13, not for why v12 and v13 can't coexist. I'd revert the last commit, keep the change in 3.1.x, and drop the "hold for 3.2.0" sequencing. One real defect I noticed while reading the mixed case: |
|
@henryiii was right about the flaw in my earlier review. My measurements no longer support my objection to the ABI bump, so I am retracting that reasoning. I also found a separate destructor issue while checking it. Retraction. My approval leaned on a three-arm cross-DSO matrix and said its result was "the behavior the internals split needs to provide." That arm — v13 producer, legacy-v12 consumer, The probe I should have run. A producer registers a class with old-style placement-new When the re-entrant load dereferences the object, pure-v12 and unbumped-mixed have the same observed outcome, When the re-entrant load only stores the pointer without touching object state:
So this probe does not provide a sound new argument either for or against the bump. In particular, it does not rescue my earlier objection. What it does identify is a separate destructor error-reporting issue: If this diagnosis looks right, I would be happy to open a separate PR against master for the destructor path. Scope. I reproduced this with clang/libc++ on Mach-O and gcc/libstdc++ on ELF, across the optimization, visibility and GIL configurations recorded in the harness. The probe exercises cross-extension interaction through the shared type registry, not arbitrary source mixing. And the pointer-only case already binds a Harness, pinned trees, build commands and raw results: https://github.com/espressolee/pybind11-6157-reentry-matrix |
Measures whether the old-style placement-new __init__ fix, built without the internals ABI bump, behaves like a pure v12 build when a second extension module re-enters during argument conversion and loads the same still unconstructed instance. Five configurations across two probe traces, reproduced on Mach-O/clang/libc++ and ELF/gcc/libstdc++ and across optimization, symbol-visibility and GIL variants. Raw output is in results/; RECEIPT.json carries the inputs, the toolchains and the claim ceiling. That ceiling is deliberately narrow. The decisive trace binds a reference to storage whose object lifetime has not begun, so it measures observable behaviour rather than defined behaviour, and cannot on its own impose a compatibility requirement. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Measures whether the old-style placement-new __init__ fix, built without the internals ABI bump, behaves like a pure v12 build when a second extension module re-enters during argument conversion and loads the same still unconstructed instance. Five configurations across two probe traces, reproduced on Mach-O/clang/libc++ and ELF/gcc/libstdc++ and across optimization, symbol-visibility and GIL variants. Raw output is in results/; RECEIPT.json carries the inputs, the toolchains and the claim ceiling. That ceiling is deliberately narrow. The decisive trace binds a reference to storage whose object lifetime has not begun, so it measures observable behaviour rather than defined behaviour, and cannot on its own impose a compatibility requirement. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
I ran the below last night, codex gpt-5.6-sol ultra. I won't have time to carefully scrutinize myself until later (weekend maybe). I just noticed, I mixed up the PR and issue numbers in the Codex transcript
UserCould you please act as if you don't know me, i.e. exclude all memory based on other sessions from this context? With that blank slate: Could you please fetch a complete view of PR 6157? Could you please try to resolve a key question: Is it safe beyond a reasonable doubt that we DO NOT bump the PYBIND11_INTERNALS_VERSION? On a chat with Henry I argued: Henry Schreiner [8:35 PM] Ralf Grosse-Kunstleve [9:21 PM] I was thinking of pressing codex or Fable (independently from the context you have already) hard to construct an example that breaks without the v13 bump. If no agent can do it, I'd think it's safe enough. Could you please use the resources on this machine to try hard to construct an example? Please do all work under: /wrk/pr6153abi_question Feel free to do anything you need under that directory, but do not modify any files outside that directory (apart from /tmp and other standard locations that you usually have write access to, e.g. your CODEX_HOME). Also feel free to use gh for read-only external access. I'm guessing that free-threaded Python is more likely to create situations that may result in undefined behavior without an internals version bump. Please use these existing from-source installations, so you can experiment with matching default and freethreaded Python builds: /wrk/cpython_installs/v3.14.4_23116f998f6_default Feel free to create venvs under /wrk/pr6153abi_question as you see fit. Could you please do a pre-flight analysis of my request. Are there additional things I should define before you start? CodexYes. I’ll treat this as a blank-slate investigation, using only this conversation, PR data, repository history, and experiments performed for this task. Nothing essential is missing. I can proceed with these assumptions:
My investigation would combine:
One methodological caveat: failure to find a reproducer cannot itself prove safety. I’ll therefore only recommend no bump if the static compatibility argument closes the plausible failure modes and the experiments corroborate it. If I find an unresolved mixed-version interpretation or lifetime risk—even without a reliable crash—I’ll call that out rather than declaring it safe. No further definition is required before starting. An optional time limit would affect how long I run concurrency stress tests, but absent one I’ll use a practical, thorough stopping point and document the coverage. UserPlease go ahead. CodexI’m starting with a contained workspace and an evidence log. I’ll first reconstruct the PR and revision topology, then split the static ABI audit and mixed-header reproducer work so the conclusions can be independently cross-checked. CodexThe local topology already reveals an important fact: the unpushed branch contains a final commit titled CodexThe static threat model now has a concrete mixed-version failure path: during a new-header legacy constructor, storage is deliberately private and the new CodexThe first full default-CPython run is decisive, and it fails in two independent ways without the bump:
With v13, the old consumer has a separate registry and rejects the object with a normal CodexThe free-threaded and reverse-direction matrices match the default build exactly. An independent 60-process harness also completed: 44 safe/control exits, 8 deterministic CodexConclusionNo. It is demonstrably unsafe to omit the bump. Keep I constructed concrete mixed-v12 failures:
The independent 60-process matrix produced:
The v13 controls separate the registries and safely reject these casts with Henry’s layout observations are correct: The argument that old-v12 was already unsafe does not resolve this: without the bump, an old caster can poison a new module’s object so that patched code itself accepts invalid storage. The mixed constructor also creates a two-storage collision absent from the pure-v12 protocol. At query time, PR 6157 already had public head There is also a separate real defect: cleanup can throw from Artifacts:
The original |
It looks like all our agents found that issue. It'd be great if you could send a separate PR. |
|
That a previous version can poison the new version with the old bug if we both are v12 is not a valid argument for bumping to v13. It's taking currently buggy behavior and saying that remains buggy if one side doesn't update. Bumping the ABI has a huge cost; it forces everything to be recompiled and support for old versions to be dropped. Keeping it v12 allows both sides to update on their own time, which speeds up the update process dramatically. Things like PyTorch cannot update to a new pybind11 ABI unless they make a major bump themselves. If this avoids a new bug, that could be reason to bump, but the examples above are things that are currently buggy. Remember ABI bumps only add forced breakages! It should be used when the two sides are incompatible. (after writing that, I asked Fable 5.1 to see what it thinks, response below) 🤖 AI text below 🤖 Your position holds. Every reproducer Codex found starts with a v12 module executing its known lazy-allocation bug on an unconstructed object. None of them is reachable from code that works today without a bump. What the new comments actually establish:
One honest caveat: there is one pattern where unbumped mixing is worse than pure v12. An old-style Recommended reply: keep v12, and fix the destructor so it never throws. On collision it should free its private storage, clear the pointer, and report via |
It means that the bug isn't fixed in general, only maybe, for a given application, until we finally bump the ABI. But if we are clear about that, I agree it's better than not doing anything at all. This seems fine:
|
This reverts commit 14e32ae.
|
Yes, though we need a reason to bump the ABI for 3.2, not just to "fix" this. I'm pretty sure we will have one, but bumping the ABI is not to force people to upgrade.
It means to fix the bug, you need to upgrade pybind11. Not bumping the ABI makes it easier to upgrade pybind11. For example, if PyTorch wants to fix it, they upgrade pybind11 (which they can do trivially if there's no ABI bump), then any extension can work correctly by upgrading pybind11 (which they can do, because there's no ABI bump). It's just like any other bugfix. ABI bumps are not for fixing bugs. They are for incompatible changes. |
Agreed. That's not what I meant to imply. But someone who prioritizes safety will need the ABI bump. I'm working on reverting the last commit. |
codex recommends (more-or-less) folding the throwing-destructor fix into this PR. @espressolee I'll tell it to do that now; I have the context already, it's just one prompt. |
|
Folding it in makes sense to me. |
|
@henryiii @espressolee — please see the codex gpt-5.6-sol ultra generated explanation below. One thing that crossed my mind but I haven't look into yet: what is the runtime impact of the "transactional rollback"? I folded the mixed-v12 storage-collision recovery into this PR in commit bda1151. Thank you both for identifying the destructor problem. The pointer-only reproducer reaches a specific sequence:
The new behavior is transactional rollback:
Raw private storage is freed through the The regression test freezes the relevant lazy-publication fragment from the pre-PR v12 caster ( Local validation:
This deliberately does not claim to make arbitrary mixed-v12 execution safe. Stale v12 code can still dereference B before updated code regains control, or retain B after returning; rollback cannot repair an escaped pointer. That is the pre-existing v12 bug and should remain explicit in the PR description / release note. This commit removes the new hard-termination behavior and leaves the updated side in a defined, retryable state when the stale call only publishes or records the pointer. |
|
@rwgk asked codex: What is the runtime impact of the "transactional rollback"? codexShort answer: the normal-path runtime impact should be negligible. “Transactional” is probably too grand a word—it is slot-level exceptional cleanup, not an always-on transaction mechanism.
A paired microbenchmark found no stable performance signal—the result changed sign between default and free-threaded Python. Minimal extension text size grew approximately 0.5–1.1% (about 1–2 KiB), which is the clearest measurable cost. The more important impact is semantic:
So I would call it “transactional rollback of pybind11’s value slot, registration, and holder state,” not a general C++ transaction. That qualification would be worth adding beneath your posted explanation. |
|
@rwgk asked codex: codexYour instinct is directionally right: this is a bug fix at the public-contract level, but it exposed a missing internal lifecycle mechanism. Previously,
For a The obvious small fix, “reject a null value pointer,” would break legacy
Most of the header growth is that transaction:
So I would explain it this way:
I would call it “missing internal machinery exposed by a bug,” rather than a missing user-facing feature. A substantially smaller change would require dropping legacy constructor behavior, bumping the internals version, or knowingly leaving some cases unsafe. That said, the explanation justifies why the fix cannot be a trivial guard; it does not by itself prove every line is minimal. |
|
@henryiii @espressolee I just updated the PR description. This PR is ready for another review. |
espressolee
left a comment
There was a problem hiding this comment.
Re-ran the paired re-entry matrix at head 23f2d0a, with the 09-02 unbumped head 4455e3f as a same-run reference. macOS arm64, Apple clang 21, -O1 -fvisibility=hidden, CPython 3.14.6 and 3.14.0rc1t, 20 fresh processes per cell; nm -gU shows no module exports load_value, which rules out exported-symbol interposition.
| trace | producer + consumer | 4455e3f | 23f2d0a |
|---|---|---|---|
| pointer-only | patched + v12 | std::terminate 20/20 |
__init__ raises RuntimeError; instance left unconstructed, get() → ValueError 20/20 |
| pointer-only | v12 + v12 | completes 20/20 | completes 20/20 |
| pointer-only | patched + patched | ValueError rejection 20/20 |
same 20/20 |
| deref | patched + v12 | SIGSEGV 20/20 | SIGSEGV 20/20 |
| deref | v12 + v12 | SIGSEGV 20/20 | SIGSEGV 20/20 |
| both | v12 producer + 23f2d0a consumer | — | identical to pure v12 |
Same on both interpreters; stderr stayed empty in every 23f2d0a cell, so I observed no unraisable cleanup report.
That matches the diff: cleanup_old_style_init_storage() is noexcept with its deallocation calls inside try/catch, so that cleanup path no longer propagates those exceptions, and the remaining collision pybind11_fail sits in complete_old_style_init(), the ordinary catchable path. One non-blocking reading: in that collision branch, if deallocate_instance_value(v_h) throws on the stale storage ("could not deregister"), old_style_init_storage has already been cleared, so the placement-constructed private value is leaked undestroyed — an internal-inconsistency path, fine to leave.
Approving: the pointer-only mixed case no longer takes the process down, no other tested arm changed outcome, and the code reads consistently with the measurements. Not exercised here: the rollback with smart_holder or custom allocators; for those I am relying on the tests in bda1151. Runner and raw results: https://github.com/espressolee/pybind11-6157-reentry-matrix/tree/main/results-23f2d0a7
The loader frame already identifies the constructor candidate, so the one-shot `self` permission only needs a frame match and a claimed flag. This removes both argument guard classes, the changes to cast.h, and the per-call TLS lookups they added. Also: - Hoist deallocate_instance_value to a detail free function and use it from instance_construction_scope. - Take the dispatcher's constructor lock before the construction scope and drop the nested critical sections it made redundant. - Keep the non-constructor path inline: the loader destructor checks for storage before the out-of-line cleanup, and the construction scope defaults to not started. - Commit old-style storage once in cpp_function::initialize, gated on is_constructor. - Share one __index__ probe across the reentrancy tests and turn the subprocess script into a plain function. Assisted-by: ClaudeCode:claude-fable-5-1
|
This is getting really complex (1K new lines!), with a lot of complexity for corner cases in something that is already broken in released pybind11. If we didn't have coding agents spewing out code, this would not have been a 1K fix. I am fine with a more practical approach over a completely all-edge-cases covered one. I ran 🤖 AI text below 🤖 Done. All fixes are applied as uncommitted changes on Fixed
Skipped
|
espressolee
left a comment
There was a problem hiding this comment.
Re-ran the same matrix at 89a5f72e: every cell identical to 23f2d0a7, on CPython 3.14.6 and 3.14.0rc1t, 20 fresh processes each. The pointer-only mixed-v12 case still raises a catchable RuntimeError from __init__ and leaves the instance unconstructed, stderr empty; the dereferencing cases still produce SIGSEGV, as pure v12 does; a v12 producer with an 89a5f72e consumer still gives the same outcomes as pure v12. 4455e3f is carried in the same runs and still terminates, so the rig can still show that failure when it is there.
So the refactor is behaviour-preserving on the two traces this harness covers. Still one toolchain and one OS, and it does not exercise smart_holder, custom allocators, or multiple inheritance.
Results: https://github.com/espressolee/pybind11-6157-reentry-matrix/tree/main/results-89a5f72e
Description
This PR addresses #6153 when every extension that may load or construct an affected instance is built with the updated headers. It intentionally retains
PYBIND11_INTERNALS_VERSION12; older inline caster code can therefore still take the pre-existing unsafe lazy-allocation path. The updated side recovers storage collisions that it observes, but cannot prevent undefined behavior already triggered in stale code or revoke escaped pointers. See the mixed-v12 investigation, the subsequent ABI discussion, and the collision-recovery notes for the nuances.Calling
cls.__new__(cls)for a pybind11-bound class creates the Python object and its value/holder slots without constructing the C++ value. Previously, loading that object from bound code could lazily allocate raw storage and treat it as a live C++ object. Reading a member could therefore return uninitialized data, and virtual dispatch could load an invalid vtable pointer and segfault. A malformed pickle can reach the same state; PyTorch has a downstream mitigation in pytorch/pytorch#194647.The complication is that pybind11's deprecated old-style placement-new
__init__and__setstate__callbacks legitimately need access to uninitialized storage. This change preserves those callbacks without making that storage available to arbitrary bound-code loads.Design
Construction is now tracked for each exact
value_and_holderslot rather than for the whole Python instance. During an old-style constructor candidate, raw storage is reserved privately by the current loader frame. A one-shot authorization lets only that candidate'sselfconversion load that exact slot, either as argument zero or through the typed cast performed inside a legacypy::objectcallback. The pointer is not published in the instance until the native callback returns successfully.All other loads while the slot is under construction raise
ValueError, including:Failed candidates clean up their private storage before overload resolution continues. Successful construction publishes and finalizes the value before return-value conversion and post-call policies run. Pure new-style constructors use the same construction-state guard, but never receive the old-style storage authorization.
If stale v12 inline caster code publishes competing storage while an updated old-style constructor holds a private reservation, the updated code attempts to roll back the value-slot, registration, and holder state when it regains control. Cleanup uses the deallocator selected by the DSO that registered the type. Failures encountered during loader-frame destructor cleanup are reported with
PyErr_WriteUnraisablerather than escaping from that destructor. This is recovery of pybind11's internal state, not a general rollback of arbitrary C++ side effects or escaped pointers.Construction-state transitions and loads are protected by the instance critical section on free-threaded Python. The construction flag reuses available bits in the existing simple-instance bitfield and nonsimple status byte;
PYBIND11_INTERNALS_VERSIONremains 12.Compatibility and tests
The regression coverage includes direct
__new__for bound classes and Python subclasses, ordinary pickle and manual__setstate__, failed old-style initialization and retry, later-argument re-entry, mixed old-/new-style overloads, nested initialization, Python multiple inheritance, and synchronized concurrent access. It also covers legacy callbacks whoseselfparameter is typed as either the bound C++ class orpy::object.Mixed-v12 collision tests freeze the relevant lazy-publication behavior from the pre-PR caster in a separate extension module. They cover failure before the constructor callback completes, collision after successful placement construction, retryability, the default holder, and
py::smart_holder.Suggested changelog entry
Prevent bound code built with updated headers from treating a pybind11 instance whose C++ value was never constructed, for example after direct
__new__, as a live C++ object. Preserve deprecated old-style placement-new constructors and pickle__setstate__callbacks while rejecting reentrant, nested, cross-base, and concurrent loads by updated casters until C++ construction completes.AI assistance
The original changes were authored with assistance from Claude. The follow-up redesign and regression coverage were developed with Codex GPT-5.6-sol ultra and independently audited by a separate agent.
📚 Documentation preview 📚: https://pybind11--6157.org.readthedocs.build/